snprintf와 sscanf — 문자열 조립과 파싱의 차이

NOTE

printf 가족(출력)과 scanf 가족(입력)의 방향 차이, 버퍼 크기 제한 유무, 반환값이 알려주는 정보의 차이를 정리하고, 커스텀 구분자 파싱을 왜 구조화된 파서로 옮기는 게 나은지 실전 사례로 살펴본다.

0. 배경

둘 다 “포맷 문자열 하나로 여러 값을 한 번에 다룬다”는 점은 같아서 이름도 헷갈리기 쉽지만, 방향이 정반대다.

  • snprintf: 여러 값 → 문자열로 조립(출력 계열, printf 가족)
  • sscanf: 문자열 → 여러 값으로 분해(입력 계열, scanf 가족)

실무에서도 두 함수는 정확히 이 대칭 위치에서 자주 쓰인다 — 설정값/로그 메시지를 조립할 땐 snprintf, 고정 포맷의 페이로드를 빠르게 뜯을 땐 sscanf.

1. 함수 시그니처

#include <stdio.h>
 
int snprintf(char *str, size_t size, const char *format, ...);
int sscanf(const char *str, const char *format, ...);
파라미터snprintfsscanf
첫 인자쓸 목적지 버퍼읽을 원본 문자열
크기 제한 인자size — 명시적으로 넘김없음 — 포맷 안의 필드 폭(%31s 등)으로만 제한 가능
... 가변인자값(값 자체를 전달)포인터(값을 저장할 변수의 주소를 전달)

sscanf도 scanf처럼 가변인자에 주소를 받는다는 점을 놓치기 쉽다 — int sst; sscanf(s, "%d", sst);처럼 &를 빼먹으면 컴파일은 되는 경우가 많지만(경고는 뜸) 실행 시 아무 메모리에나 값을 써버리는 미정의 동작이 된다.

2. 방향과 버퍼 안전성

2.1 snprintf — 크기를 직접 넘기니 안전하게 자를 수 있다

char buf[16];
int n = snprintf(buf, sizeof(buf), "%s:%d", name, port);
  • size 이상은 절대 쓰지 않는다 — 넘치는 부분은 자르고(truncate), 항상 널 종료 문자를 보장한다(size가 0이 아닌 한).
  • 오래된 sprintf는 크기 제한이 아예 없어서 버퍼보다 긴 결과가 그대로 스택/힙을 덮어쓸 수 있다 — 그래서 실무에서는 sprintf를 거의 안 쓰고 snprintf만 쓴다.

2.2 sscanf — %s/%[...]에 폭을 안 주면 그대로 버퍼오버플로우

char tai[32];
sscanf(payload, "%s", tai);        /* 위험 — payload가 32자보다 길면 오버플로우 */
sscanf(payload, "%31s", tai);      /* 안전 — 최대 31자 + 널 종료 1자 = 32 */
sscanf(payload, "%31[^|]", tai);   /* '|' 전까지 최대 31자만 읽음 — 파이프 구분 커스텀 포맷 파서가 자주 쓰는 방식 */

sscanf에는 snprintf의 size 같은 “목적지 버퍼 크기” 인자가 아예 없다. 안전하게 쓰려면 포맷 문자열 안에 필드 폭을 사람이 직접 계산해서 박아넣어야 하고, 그 폭이 실제 배열 크기와 어긋나면(리팩토링 중 배열 크기만 바꾸고 포맷 문자열은 안 고치는 실수 등) 오버플로우가 조용히 재발한다 — snprintf(buf, sizeof(buf), ...)처럼 sizeof로 자동 연동할 방법이 없다는 게 근본적인 차이.

3. 반환값이 말해주는 것

반환값 의미실패/이상 상황 판단
snprintf버퍼가 충분히 컸다면 널 종료 문자를 뺴고 실제로 쓰였을 길이(출력 성공 여부와 무관하게 항상 “원했던 길이”)반환값 >= size면 **잘렸다(truncate)**는 뜻 — 인코딩 에러 등만 음수
sscanf성공적으로 값을 읽어서 대입한 변수 개수포맷 자체가 하나도 안 맞으면 매칭 전에 EOF 반환. 그 외엔 “몇 개까지 성공했는지”만 알려줄 뿐, 어느 필드가 왜 실패했는지는 알려주지 않는다
char buf[8];
int n = snprintf(buf, sizeof(buf), "%s", "hello world");
/* n == 11 (실제로 쓰고 싶었던 길이) 인데 sizeof(buf) == 8 이므로 잘렸다는 걸 n >= 8 로 알 수 있음 */
 
int matched = sscanf(payload, "%31[^|]|%d", tai, &sst);
/* matched == 2 여야 둘 다 성공. matched == 1이면 sst 파싱 실패인데,
   payload 포맷이 잘못된 건지 sst 자리에 숫자가 아닌 문자가 온 건지는 이 값만으론 구분 불가 */

이 비대칭이 실무에서 중요한 이유: snprintf는 “잘렸는지 아닌지”라는 단일하고 명확한 실패 모드만 있는 반면, sscanf는 “포맷 불일치/타입 불일치/구분자 누락/입력 자체가 짧음”이 전부 “매칭 개수 부족”이라는 하나의 신호로 뭉뚱그려져서, 실패했을 때 정확히 뭐가 잘못됐는지 반환값만으로는 재구성하기 어렵다.

4. 실전 사례 — 왜 sscanf 파이프 파싱을 버렸나

요청 바디를 커스텀 구분자 포맷으로 파싱하던 코드가 있었다.

sscanf(payload, "%31[^|]|%d|%15s", field_a, &field_b, field_c);

a|b|c를 파이프로 구분한 포맷이었는데, 문제는:

  • 필드가 하나 빠지거나 순서가 바뀌어도 matched가 줄어들 뿐, 어느 필드가 문제인지 클라이언트에게 알려줄 만한 정보가 없다(400 에러 메시지를 구체적으로 못 만듦).
  • 구분자(|)가 페이로드 값 안에 우연히 섞여 들어가면(예: field_c 값에 |가 포함) 그 뒤 필드가 전부 밀려서 파싱된다 — 포맷 자체가 값의 내용에 의존적이라 취약함.
  • 타입 검증이 사실상 없다 — %d 자리에 문자가 오면 그 필드부터 매칭이 멈추지만, 이후 필드들의 상태(초기화됐는지)는 호출자가 일일이 신경 써야 함.

실제 해결은 라이브러리 교체였다 — JSON 파서(json_loads() + json_object_get() 계열 API)로 필드별 존재 여부·타입을 명시적으로 확인하고 실패 지점을 정확히 알 수 있는 구조로 바꿈. sscanf가 “포맷만 맞으면 빠르게 뜯어낼 수 있는” 편리함이 있지만, 구조화된 검증이 필요한 외부 입력 파싱에는 근본적으로 안 맞는 도구였다는 게 이 마이그레이션의 핵심 동기였다.

5. 요약 비교

snprintfsscanf
계열printf 가족 (출력)scanf 가족 (입력)
방향값들 → 문자열문자열 → 값들
가변인자로 받는 것값 그 자체값을 저장할 변수의 주소(&)
버퍼 크기 제한size 인자로 명시, 자동으로 안전필드 폭(%31s)을 직접 계산해서 넣어야 함 — 안 넣으면 오버플로우
반환값”원했던 길이” (truncate 여부 판단 가능)“성공적으로 매칭된 개수” (실패 원인은 알 수 없음)
적합한 용도출력 문자열 조립 전반포맷이 아주 단순·고정적이고 신뢰 가능한 내부 데이터를 빠르게 뜯을 때
부적합한 용도(특별히 없음 — sprintf 대체제로 항상 우선)외부/신뢰 못 할 입력의 구조 검증이 필요할 때 → JSON 파서 등 전용 라이브러리가 낫다

관련 문서